import { Parser, StateFn, EOF } from './parser';
import * as css from 'css';

export class HTMLParser extends Parser {
  protected state: StateFn = this.DATA;

  protected currentToken: null | Record<string, any> = null;
  protected currentTextNode: null | Record<string, any> = null;
  protected currentAttribute: null | Record<string, any> = null;

  protected stack: Record<string, any>[] = [{
    type: 'document',
    children: [],
  }];

  protected rules: css.Rule[] = [];

  parse(text: string) {
    super.parse(text);
    return this.stack[0];
  }

  emit(token: Record<string, any>) {
    const top = this.stack[this.stack.length - 1];

    if (token.type === 'startTag') {
      const element = {
        type: 'element',

        children: [],

        tagName: token.tagName,
        attributes: token.attributes,
      }

      top.children.push(element);
      
      if (!token.isSelfClosing) {
        this.stack.push(element);
      }

      this.computeCSS(element);

      this.currentTextNode = null;
    } else if (token.type === 'endTag') {
      if (top.tagName !== token.tagName) {
        throw new Error('Tag start end doesn\'t match');
      } else {
        if (top.tagName === 'style') {
          this.addCSSRules(top.children[0].content);
        }

        this.stack.pop();
      }

      this.currentTextNode = null;
    } else if (token.type === 'text') {
      if (this.currentTextNode === null) {
        this.currentTextNode = {
          type: 'text',
          content: '',
        }

        top.children.push(this.currentTextNode);
      }
      this.currentTextNode.content += token.content;
    }
  }

  protected addCSSRules(text: string) {
    this.rules.push(...css.parse(text).stylesheet?.rules ?? []);
  }

  protected computeCSS(element: Record<string, any>) {
    const elements = this.stack.slice().reverse();

    if (element.computedStyle == null) {
      element.computedStyle = {};
    }

    for (let rule of this.rules) {
      const selectorParts = rule.selectors ? rule.selectors[0].split(' ').reverse() : [];

      if (!this.match(element, selectorParts[0])) {
        continue;
      }

      let isMatched = false;

      for (let i = 0, j = 1; i < elements.length; i++) {
        if (!this.match(elements[i], selectorParts[j])) {
          continue;
        }
        j++;

        if (j >= selectorParts.length) {
          isMatched = true;
          break;
        }
      }

      if (isMatched) {
        const computedStyle = element.computedStyle;

        const specificity = this.specificity(rule.selectors ? rule.selectors[0] : '');

        for (const declaration of rule.declarations ?? []) {
          const property = (declaration as css.Declaration).property;
          if (property == null) {
            continue;
          }
          
          if (computedStyle[property] == null) {
            computedStyle[property] = {};
          }

          const value = (declaration as css.Declaration).value;

          if (computedStyle[property].specificity == null) {
            computedStyle[property].value = value;
            computedStyle[property].specificity = specificity;
          } else if (this.compare(computedStyle[property].specificity, specificity) <= 0) {
            computedStyle[property].value = value;
            computedStyle[property].specificity = specificity;
          }
        }
      }
    }
  }

  protected match(element: Record<string, any>, selector: string) {
    if (selector === '' || element.type === 'text') {
      return false;
    }

    switch (selector.charAt(0)) {
      case '#': {
        if (element.attributes && element.attributes['id'] === selector.replace('#', '')) {
          return true;
        }
      }

      case '.': {
        if (element.attributes && element.attributes['class'] === selector.replace('.', '')) {
          return true;
        }
      }
    }

    if (element.tagName === selector) {
      return true;
    }

    return false;
  }

  protected specificity(selector: string): [number, number, number, number] {
    const priority: [number, number, number, number] = [0, 0, 0, 0];
    const selectorParts = selector.split(' ');

    for (const part of selectorParts) {
      if (part.charAt(0) === '#') {
        priority[1] += 1;
      } else if (part.charAt(0) === '.') {
        priority[2] += 1;
      } else {
        priority[3] += 1;
      }
    }

    return priority;
  }

  protected compare(
    priority_1: [number, number, number, number],
    priority_2: [number, number, number, number],
  ) {
    for (let i = 0; i < 4; i++) {
      if (priority_1[i] - priority_2[i] > 0) {
        return 1;
      } else if (priority_1[i] - priority_2[i] < 0) {
        return -1;
      }
    }
    return 0;
  }

  /**
   * 13.2.5.1 Data state
    Consume the next input character:

    U+0026 AMPERSAND (&)
      Set the return state to the data state. Switch to the character reference state.
    U+003C LESS-THAN SIGN (<)
      Switch to the tag open state.
    U+0000 NULL
      This is an unexpected-null-character parse error. Emit the current input character as a character token.
    EOF
      Emit an end-of-file token.
    Anything else
      Emit the current input character as a character token.
    13.2.5.2 RCDATA state
   */
  private DATA(char: string | typeof EOF) {
    switch (char) {
      case '<': {
        return this.TAG_OPEN;
      }

      case EOF: {
        this.emit({ type: 'EOF' });
        return this.END;
      }

      default: {
        this.emit({ type: 'text', content: char });
        return this.DATA;
      }
    }
  }

  /**
   * 13.2.5.6 Tag open state
    Consume the next input character:

    U+0021 EXCLAMATION MARK (!)
      Switch to the markup declaration open state.
    U+002F SOLIDUS (/)
      Switch to the end tag open state.
    ASCII alpha
      Create a new start tag token, set its tag name to the empty string. Reconsume in the tag name state.
    U+003F QUESTION MARK (?)
      This is an unexpected-question-mark-instead-of-tag-name parse error. Create a comment token whose data is the empty string. Reconsume in the bogus comment state.
    EOF
      This is an eof-before-tag-name parse error. Emit a U+003C LESS-THAN SIGN character token and an end-of-file token.
    Anything else
      This is an invalid-first-character-of-tag-name parse error. Emit a U+003C LESS-THAN SIGN character token. Reconsume in the data state.
   */
  private TAG_OPEN(char: string | typeof EOF) {
    switch (char) {
      case '/': {
        return this.END_TAG_OPEN;
      }
    }

    if (typeof char === 'string' && char.match(/^[a-zA-Z]$/)) {
      this.currentToken = {
        type: 'startTag',
        tagName: '',
      };

      return this.TAG_NAME(char);
    }
  }

  /**
   * 13.2.5.7 End tag open state
    Consume the next input character:

    ASCII alpha
      Create a new end tag token, set its tag name to the empty string. Reconsume in the tag name state.
    U+003E GREATER-THAN SIGN (>)
      This is a missing-end-tag-name parse error. Switch to the data state.
    EOF
      This is an eof-before-tag-name parse error. Emit a U+003C LESS-THAN SIGN character token, a U+002F SOLIDUS character token and an end-of-file token.
    Anything else
      This is an invalid-first-character-of-tag-name parse error. Create a comment token whose data is the empty string. Reconsume in the bogus comment state.
    13.2.5.8 Tag name state
   */
  private END_TAG_OPEN(char: string | typeof EOF) {
    if (typeof char === 'string' && char.match(/^[a-zA-Z]$/)) {
      this.currentToken = {
        type: 'endTag',
        tagName: '',
      };
      return this.TAG_NAME(char);
    }
  }
  
  /**
   * 13.2.5.8 Tag name state
    Consume the next input character:

    U+0009 CHARACTER TABULATION (tab)
    U+000A LINE FEED (LF)
    U+000C FORM FEED (FF)
    U+0020 SPACE
      Switch to the before attribute name state.
    U+002F SOLIDUS (/)
      Switch to the self-closing start tag state.
    U+003E GREATER-THAN SIGN (>)
      Switch to the data state. Emit the current tag token.
    ASCII upper alpha
      Append the lowercase version of the current input character (add 0x0020 to the character's code point) to the current tag token's tag name.
    U+0000 NULL
      This is an unexpected-null-character parse error. Append a U+FFFD REPLACEMENT CHARACTER character to the current tag token's tag name.
    EOF
      This is an eof-in-tag parse error. Emit an end-of-file token.
    Anything else
      Append the current input character to the current tag token's tag name.
  */
  private TAG_NAME(char: string | typeof EOF): StateFn | void {
    if (typeof char === 'string' && char.match(/^[\t\n\f ]$/)) {
      return this.BEFORE_ATTRIBUTE_NAME;
    }

    if (char === '/') {
      return this.SELF_CLOSING_START_TAG;
    }

    if (char === '>') {
      this.emit(this.currentToken!);
      return this.DATA;
    }

    if (char === EOF) {
      return;
    }

    if (this.currentToken !== null) {
      this.currentToken.tagName += char;
    }

    return this.TAG_NAME;
  }

  /**
   * 13.2.5.32 Before attribute name state
    Consume the next input character:

    U+0009 CHARACTER TABULATION (tab)
    U+000A LINE FEED (LF)
    U+000C FORM FEED (FF)
    U+0020 SPACE
      Ignore the character.

    U+002F SOLIDUS (/)
    U+003E GREATER-THAN SIGN (>)
    EOF
      Reconsume in the after attribute name state.

    U+003D EQUALS SIGN (=)
      This is an unexpected-equals-sign-before-attribute-name parse error. Start a new attribute in the current tag token. Set that attribute's name to the current input character, and its value to the empty string. Switch to the attribute name state.

    Anything else
      Start a new attribute in the current tag token. Set that attribute name and value to the empty string. Reconsume in the attribute name state.
   */
  private BEFORE_ATTRIBUTE_NAME(char: string | typeof EOF) {
    if (typeof char === 'string' && char.match(/^[\t\n\f ]$/)) {
      return this.BEFORE_ATTRIBUTE_NAME;
    }

    switch (char) {
      case '>':
      case '/':
      case EOF: {
        return this.AFTER_ATTRIBUTE_NAME(char);
      }

      case '=': {
        return;
      }
    }

    this.currentAttribute = {
      name: '',
      value: '',
    };

    return this.ATTRIBUTE_NAME(char);
  }

  /**
   * 13.2.5.33 Attribute name state
    Consume the next input character:

    U+0009 CHARACTER TABULATION (tab)
    U+000A LINE FEED (LF)
    U+000C FORM FEED (FF)
    U+0020 SPACE
    U+002F SOLIDUS (/)
    U+003E GREATER-THAN SIGN (>)
    EOF
      Reconsume in the after attribute name state.
    U+003D EQUALS SIGN (=)
      Switch to the before attribute value state.
    ASCII upper alpha
      Append the lowercase version of the current input character (add 0x0020 to the character's code point) to the current attribute's name.
    U+0000 NULL
      This is an unexpected-null-character parse error. Append a U+FFFD REPLACEMENT CHARACTER character to the current attribute's name.
    U+0022 QUOTATION MARK (")
    U+0027 APOSTROPHE (')
    U+003C LESS-THAN SIGN (<)
      This is an unexpected-character-in-attribute-name parse error. Treat it as per the "anything else" entry below.
    Anything else
      Append the current input character to the current attribute's name.
   */
  private ATTRIBUTE_NAME(char: string | typeof EOF) {
    if (typeof char === 'string' && char.match(/^[\t\n\f ]$/) || char === '/' || char === '>') {
      return this.AFTER_ATTRIBUTE_NAME(char);
    }

    switch (char) {
      case '=': {
        return this.BEFORE_ATTRIBUTE_VALUE;
      }

      case '\'':
      case '"':
      case '<': {
        return;
      }
    }

    if (this.currentAttribute !== null) {
      this.currentAttribute.name += char.toString();
    }

    return this.ATTRIBUTE_NAME;
  }

  /**
   * 13.2.5.35 Before attribute value state
    Consume the next input character:

    U+0009 CHARACTER TABULATION (tab)
    U+000A LINE FEED (LF)
    U+000C FORM FEED (FF)
    U+0020 SPACE
      Ignore the character.
    U+0022 QUOTATION MARK (")
      Switch to the attribute value (double-quoted) state.
    U+0027 APOSTROPHE (')
      Switch to the attribute value (single-quoted) state.
    U+003E GREATER-THAN SIGN (>)
      This is a missing-attribute-value parse error. Switch to the data state. Emit the current tag token.
    Anything else
      Reconsume in the attribute value (unquoted) state.
   */
  private BEFORE_ATTRIBUTE_VALUE(char: string | typeof EOF) {
    if (typeof char === 'string' && char.match(/^[\t\n\f ]$/) || char === '/' || char === '>') {
      return this.BEFORE_ATTRIBUTE_VALUE;
    }

    switch (char) {
      case '\'': {
        return this.SINGLE_QUOTED_ATTRIBUTE_VALUE;
      }

      case '"': {
        return this.DOUBLE_QUOTED_ATTRIBUTE_VALUE;
      }

      case '>': {
        return;
      }
    }

    return this.UNQUOTED_ATTRIBUTE_VALUE(char);
  }

  /**
   * 13.2.5.37 Attribute value (single-quoted) state
    Consume the next input character:

    U+0027 APOSTROPHE (')
      Switch to the after attribute value (quoted) state.

    U+0026 AMPERSAND (&)
      Set the return state to the attribute value (single-quoted) state. Switch to the character reference state.

    U+0000 NULL
      This is an unexpected-null-character parse error. Append a U+FFFD REPLACEMENT CHARACTER character to the current attribute's value.

    EOF
      This is an eof-in-tag parse error. Emit an end-of-file token.
    
    Anything else
      Append the current input character to the current attribute's value.
   */
  private SINGLE_QUOTED_ATTRIBUTE_VALUE(char: string | typeof EOF) {
    switch (char) {
      case '\'': {
        if (this.currentToken !== null && this.currentAttribute !== null) {
          this.currentToken.attributes ??= {};
          this.currentToken.attributes[this.currentAttribute.name] = this.currentAttribute.value;
          return this.AFTER_QUOTED_ATTRIBUTE_VALUE;
        }
      }

      case EOF: {
        return;
      }
    }

    if (this.currentAttribute !== null) {
      this.currentAttribute.value += char;
    }

    return this.SINGLE_QUOTED_ATTRIBUTE_VALUE;
  }

  /**
   * 13.2.5.36 Attribute value (double-quoted) state
    Consume the next input character:

    U+0022 QUOTATION MARK (")
      Switch to the after attribute value (quoted) state.
    U+0026 AMPERSAND (&)
      Set the return state to the attribute value (double-quoted) state. Switch to the character reference state.
    U+0000 NULL
     This is an unexpected-null-character parse error. Append a U+FFFD REPLACEMENT CHARACTER character to the current attribute's value.
    EOF
     This is an eof-in-tag parse error. Emit an end-of-file token.
    Anything else
      Append the current input character to the current attribute's value.
   */
  private DOUBLE_QUOTED_ATTRIBUTE_VALUE(char: string | typeof EOF) {
    switch (char) {
      case '"': {
        if (this.currentToken !== null && this.currentAttribute !== null) {
          this.currentToken.attributes ??= {};
          this.currentToken.attributes[this.currentAttribute.name] = this.currentAttribute.value;
          return this.AFTER_QUOTED_ATTRIBUTE_VALUE;
        }
      }

      case EOF: {
        return;
      }
    }

    if (this.currentAttribute !== null) {
      this.currentAttribute.value += char;
    }

    return this.DOUBLE_QUOTED_ATTRIBUTE_VALUE;
  }

  /**
   * 13.2.5.38 Attribute value (unquoted) state
    Consume the next input character:

    U+0009 CHARACTER TABULATION (tab)
    U+000A LINE FEED (LF)
    U+000C FORM FEED (FF)
    U+0020 SPACE
      Switch to the before attribute name state.

    U+0026 AMPERSAND (&)
      Set the return state to the attribute value (unquoted) state. Switch to the character reference state.

    U+003E GREATER-THAN SIGN (>)
      Switch to the data state. Emit the current tag token.

    U+0000 NULL
      This is an unexpected-null-character parse error. Append a U+FFFD REPLACEMENT CHARACTER character to the current attribute's value.
    U+0022 QUOTATION MARK (")
    U+0027 APOSTROPHE (')
    U+003C LESS-THAN SIGN (<)
    U+003D EQUALS SIGN (=)

    U0060 GRAVE ACCENT (`)
      This is an unexpected-character-in-unquoted-attribute-value parse error. Treat it as per the "anything else" entry below.

    EOF
      This is an eof-in-tag parse error. Emit an end-of-file token.

    Anything else
      Append the current input character to the current attribute's value.
   */
  private UNQUOTED_ATTRIBUTE_VALUE(char: string | typeof EOF) {
    if (typeof char === 'string' && char.match(/^[\t\n\f ]$/)) {
      return this.BEFORE_ATTRIBUTE_NAME;
    }

    if (char === '>') {
      this.emit(this.currentToken!);
      return this.DATA;
    }

    switch (char) {
      case '"':
      case '\'':
      case '<':
      case '=':
      case '`':
      case EOF: {
        return;
      }
    }

    if (this.currentAttribute !== null){
      this.currentAttribute.value += char;
    }
  
    return this.UNQUOTED_ATTRIBUTE_VALUE;
  }

  /**
   * 13.2.5.39 After attribute value (quoted) state
    Consume the next input character:

    U+0009 CHARACTER TABULATION (tab)
    U+000A LINE FEED (LF)
    U+000C FORM FEED (FF)
    U+0020 SPACE
      Switch to the before attribute name state.

    U+002F SOLIDUS (/)
      Switch to the self-closing start tag state.

    U+003E GREATER-THAN SIGN (>)
      Switch to the data state. Emit the current tag token.

    EOF
      This is an eof-in-tag parse error. Emit an end-of-file token.

    Anything else
      This is a missing-whitespace-between-attributes parse error. Reconsume in the before attribute name state.
   */
  private AFTER_QUOTED_ATTRIBUTE_VALUE(char: string | typeof EOF) {
    if (typeof char === 'string' && char.match(/^[\t\n\f ]$/)) {
      return this.BEFORE_ATTRIBUTE_NAME;
    }

    if (char === '/') {
      return this.SELF_CLOSING_START_TAG;
    }

    if (char === '>') {
      this.emit(this.currentToken!);
      return this.DATA;
    }
  }

  /**
   * 13.2.5.34 After attribute name state
    Consume the next input character:

    U+0009 CHARACTER TABULATION (tab)
    U+000A LINE FEED (LF)
    U+000C FORM FEED (FF)
    U+0020 SPACE
      Ignore the character.

    U+002F SOLIDUS (/)
      Switch to the self-closing start tag state.

    U+003D EQUALS SIGN (=)
      Switch to the before attribute value state.

    U+003E GREATER-THAN SIGN (>)
      Switch to the data state. Emit the current tag token.

    EOF
      This is an eof-in-tag parse error. Emit an end-of-file token.

      Anything else
      Start a new attribute in the current tag token. Set that attribute name and value to the empty string. Reconsume in the attribute name state.
   */
  private AFTER_ATTRIBUTE_NAME(char: string | typeof EOF): StateFn | void {
    if (typeof char === 'string' && char.match(/^[\t\n\f ]$/)) {
      return this.AFTER_ATTRIBUTE_NAME;
    }

    if (char === '/') {
      return this.SELF_CLOSING_START_TAG;
    }

    if (char === '=') {
      return this.BEFORE_ATTRIBUTE_VALUE;
    }

    if (char === '>') {
      this.emit(this.currentToken!);
      return this.DATA;
    }

    if (char === EOF) {
      return;
    }

    this.currentAttribute = {
      name: '',
      value: '',
    };

    return this.ATTRIBUTE_NAME(char);
  }

  /**
   * 13.2.5.40 Self-closing start tag state
    Consume the next input character:

    U+003E GREATER-THAN SIGN (>)
      Set the self-closing flag of the current tag token. Switch to the data state. Emit the current tag token.
    EOF
      This is an eof-in-tag parse error. Emit an end-of-file token.
    Anything else
      This is an unexpected-solidus-in-tag parse error. Reconsume in the before attribute name state.
   */
  private SELF_CLOSING_START_TAG(char: string | typeof EOF) {
    if (char === '>') {
      if (this.currentToken !== null) {
        this.currentToken.isSelfClosing = true;
      }
      this.emit(this.currentToken!);
      return this.DATA;
    }
  }

  private END(char: string | typeof EOF) {
    return;
  }
}
